Skip to content

Receiver fallback typestate#1558

Merged
benalleng merged 10 commits into
payjoin:masterfrom
spacebear21:fallback-typestate
Jun 5, 2026
Merged

Receiver fallback typestate#1558
benalleng merged 10 commits into
payjoin:masterfrom
spacebear21:fallback-typestate

Conversation

@spacebear21

@spacebear21 spacebear21 commented May 14, 2026

Copy link
Copy Markdown
Collaborator

Supersedes #1542.

Receiver counterpart to #1557 . It's a much bigger PR because a) the receiver state machine is more complicated than the sender's, and b) it also implements the "protocol failure" fallback path (i.e. not a manually-initiated cancel, but an irrecoverable error that would also warrant broadcasting the fallback tx).

stateDiagram-v2
      [*] --> Initialized

      Initialized --> UncheckedOriginalPayload: RetrievedOriginalPayload
      UncheckedOriginalPayload --> MaybeInputsOwned: CheckedBroadcastSuitability

      state "9 HasFallbackTx in-protocol states" as InProtocol {
          MaybeInputsOwned --> MaybeInputsSeen: CheckedInputsNotOwned
          MaybeInputsSeen --> OutputsUnknown: CheckedNoInputsSeenBefore
          OutputsUnknown --> WantsOutputs: IdentifiedReceiverOutputs
          WantsOutputs --> WantsInputs: CommittedOutputs
          WantsInputs --> WantsFeeRange: CommittedInputs
          WantsFeeRange --> ProvisionalProposal: AppliedFeeRange
          ProvisionalProposal --> PayjoinProposal: FinalizedProposal
          PayjoinProposal --> Monitor: PostedPayjoinProposal
      }

      UncheckedOriginalPayload --> HasReplyableError: GotReplyableError, fallback_tx is None (broadcast unverified)
      InProtocol --> HasReplyableError: GotReplyableError, fallback_tx is Some

      InProtocol --> PendingFallback: Cancelled (cancel from any HasFallbackTx state)
      PayjoinProposal --> PendingFallback: ProtocolFailed (fatal process_response)
      HasReplyableError --> PendingFallback: Cancelled or ProtocolFailed, when fallback_tx is Some

      Initialized --> Closed: Closed(Cancel) on cancel, or Closed(Failure) on fatal process_response
      UncheckedOriginalPayload --> Closed: Closed(Cancel) on cancel
      HasReplyableError --> Closed: Closed(Cancel) on cancel, or Closed(Failure) on process_error_response, when fallback_tx is None
      Monitor --> Closed: check_payment observes Success, FallbackBroadcasted, or PayjoinProposalSent

      PendingFallback --> Closed: close() emits Closed(Cancel) after Cancelled entry, or Closed(Failure) after ProtocolFailed entry

      Closed --> [*]

      note right of PendingFallback
          Holds the wallet's obligation to broadcast
          or explicitly discard the original tx.
          Stays active until close() so it survives
          resume cycles.
      end note
Loading

Planned with Claude Opus 4.7, implemented by Codex 5.5

Pull Request Checklist

Please confirm the following before requesting review:

@spacebear21 spacebear21 changed the title Fallback typestate Receiver fallback typestate May 14, 2026
@arminsabouri arminsabouri mentioned this pull request May 15, 2026
2 tasks
@spacebear21
spacebear21 force-pushed the fallback-typestate branch 2 times, most recently from f4454b7 to f175e01 Compare May 15, 2026 21:39
@spacebear21
spacebear21 force-pushed the fallback-typestate branch from f175e01 to 56641ee Compare May 15, 2026 22:24
@coveralls

coveralls commented May 15, 2026

Copy link
Copy Markdown
Collaborator

Coverage Report for CI Build 26970500536

Coverage increased (+0.04%) to 85.397%

Details

  • Coverage increased (+0.04%) from the base build.
  • Patch coverage: 120 uncovered changes across 3 files (755 of 875 lines covered, 86.29%).
  • 22 coverage regressions across 3 files.

Uncovered Changes

File Changed Covered %
payjoin/src/core/receive/v2/mod.rs 578 511 88.41%
payjoin-cli/src/app/v2/mod.rs 65 13 20.0%
payjoin-cli/src/db/v2.rs 1 0 0.0%
Total (6 files) 875 755 86.29%

Coverage Regressions

22 previously-covered lines in 3 files lost coverage.

File Lines Losing Coverage Coverage
payjoin-cli/src/app/v2/mod.rs 15 51.58%
payjoin/src/core/receive/v2/mod.rs 6 91.25%
payjoin-cli/src/app/v1.rs 1 69.33%

Coverage Stats

Coverage Status
Relevant Lines: 14607
Covered Lines: 12474
Line Coverage: 85.4%
Coverage Strength: 373.51 hits per line

💛 - Coveralls

@DanGould

Copy link
Copy Markdown
Member

I read the writeups and concept ACK. I wonder if MayBroadcastFallback is a more appropriate name that implies an action than HasFallback.

@spacebear21

Copy link
Copy Markdown
Collaborator Author

I wonder if MayBroadcastFallback is a more appropriate name that implies an action than HasFallback.

In the latest iteration of this PR the typestate is called PendingFallback and HasFallbackTx is a sealed marker trait for receiver states that hold a verified broadcastable fallback tx. Does that sound better?

@spacebear21
spacebear21 force-pushed the fallback-typestate branch 2 times, most recently from e6828cc to 21722b1 Compare May 21, 2026 02:56
@spacebear21
spacebear21 marked this pull request as ready for review May 21, 2026 02:56
@spacebear21
spacebear21 force-pushed the fallback-typestate branch 3 times, most recently from 3993880 to 174281c Compare May 22, 2026 03:05
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingFallback {
fallback_tx: bitcoin::Transaction,
outcome: SessionOutcome,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I described here and here how we could get rid of the Cancel/Failure SessionOutcome variants since this information can be inferred from the event log. Then we wouldn't need to pass outcome through here. But I'd prefer to address that in a follow-up for both the sender and receiver together.

@xstoicunicornx xstoicunicornx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only went through the payjoin core changes so far but felt it was a good stopping point for some questions/comments.

Comment thread payjoin/src/core/persist.rs Outdated
Comment on lines +1454 to 1467
match (process_post_res(res, ohttp_context), pending) {
(Ok(_), Some(pending_fallback)) =>
MaybeTerminalSuccessTransition::advance(event, pending_fallback),
(Ok(_), None) => MaybeTerminalSuccessTransition::terminate(event),
(Err(e), _) if !e.is_fatal() =>
MaybeTerminalSuccessTransition::transient(protocol_error(e)),
(Err(e), Some(pending_fallback)) => MaybeTerminalSuccessTransition::fatal_advance(
event,
pending_fallback,
protocol_error(e),
),
(Err(e), None) =>
MaybeTerminalSuccessTransition::fatal_terminate(event, protocol_error(e)),
}

@xstoicunicornx xstoicunicornx May 27, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was the main piece I wanted to ask about/check my understanding on.

It seems like there are two types of paths here:

  1. Return a transition where persistence will only result in persistence error
  2. Return a transition where persistence may unwrap a protocol error

Is this a correct understanding? If so I am not sure how I feel about that discrepancy. It seems like it would require fatal_advance and transient error paths to preserve the prior receiver state so that the session can be closed via .cancel().

The current version of this method established this pattern, but I am not even sure how the current version would close the session if it was a transient error? Would it have to keep the previous receiver state just in case so that it can rerun create_error_request and process_error_response? Also Receiver<Initialized>::process_response seems to follow a similar pattern, but at least there it consistently only returns transitions that, when persisted, only produce persister errors (though it does make me have more questions on why we handle the other transition states at all when they don't get persisted).

Is there a reason we have to try to handle the process_post_req error (Receiver<Initialized>::process_response doesn't seem to)? Would it be acceptable to just map the errors to the appropriate next state (either PendingFallback or Closed)? Maybe if transient error really is a NoOp within the persister it should return current state?

Apologies for the rambling...

Edit: Looks like MaybeFatalTransition also follows this sort of pattern so maybe this is just me finally learning how the transitions work. Though MaybeFatalTransition does return an error_state wrapped within the error, not sure if it actually meant to be used? I assume yes since its trying to advance to HasReplyableError state?

Comment thread payjoin/src/core/receive/v2/mod.rs
let expected_tx = PARSED_ORIGINAL_PSBT.clone().extract_tx_unchecked_fee_rate();
let replyable_error = mock_err();

let test_cases = vec![

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be good to add a test vector for:

  • SessionEvent::ProtocolFailed results in ReceiveSession::PendingFallback
  • closing ReceiveSession::PendingFallback results in ReceiveSession::Closed

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added additional vectors to cover these in 8f642a4

@DanGould DanGould added this to the payjoin-1.0 milestone Jun 2, 2026
@benalleng
benalleng force-pushed the fallback-typestate branch from 174281c to a1865a1 Compare June 2, 2026 18:58
Comment thread payjoin/src/core/persist.rs Outdated
Comment on lines +485 to +486
MaybeTerminalSuccessOutcome::FatalAdvance(event, _next_state, error) =>
(PersistActions::Save(event), Err(ApiError::Fatal(error))),

@benalleng benalleng Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am going to transition this (PersistActions::Save(event), Err(ApiError::Fatal(error))), to instead be a (PersistActions::Save(event), Err(ApiError::FatalWithState(error, {next/error}_state))), so that we can still transition to the next state and propagate the error at the same time. This pattern is also used in

Err(Rejection::ReplyableError(RejectReplyableError(event, error_state, error))) =>
(PersistActions::Save(event), Err(ApiError::FatalWithState(error, error_state))),

Introduce a sealed::FallbackTx trait with a non-Option fallback_tx()
method, implemented inside the sealed module for the in-protocol
receiver states whose contract includes a confirmed broadcastable
fallback. Expose access through HasFallbackTx, a public marker trait
that has no methods of its own and is implemented for any type
satisfying sealed::FallbackTx via a blanket impl. External crates can
bound on HasFallbackTx but cannot implement it, and the method itself
is only callable from inside the receive::v2 module where the sealed
trait is in scope.

- UncheckedOriginalPayload is deliberately excluded;
it holds the sender's Original PSBT but has not yet run
check_broadcast_suitability, so the PSBT is not yet verified as
broadcastable.
- HasReplyableError is also excluded; it will gain an
optional fallback field in a later commit and continues to model the
absent-fallback case at runtime.

To avoid naming conflicts in intermediate commits, the existing
`fallback_tx() -> Option<Transaction>` implementation is renamed
to `maybe_fallback_tx`. It is removed entirely in a later commit.
PendingFallback represents a receiver session that was cancelled or
hit a fatal protocol error, and has a fallback transaction available
to broadcast.

While the session sits in PendingFallback the implementer holds an
obligation to broadcast, discard, or otherwise handle the fallback
transaction (e.g. save it to wallet DB for later broadcasting). This
state is preserved across restarts and session replays until the
implemeter calls `close()`, indicating that the handoff of the
fallback transaction is complete and no longer a payjoin concern.
HasReplyableError represents a receiver session that hit a replyable
error before reaching PendingFallback. The struct must model the
runtime fact that some sources can hand it a verified broadcastable
fallback and others cannot. Encoding the field as
Option<Transaction> keeps that distinction at the type level without
weakening the HasFallback trait contract.
Introduce MaybeTerminalTransition for the no-error fork (used by
cancel) and MaybeTerminalSuccessTransition for the error-bearing
fork (used by process_error_response). Both expose advance and
terminate constructors that map to Save and SaveAndClose actions
respectively. The success variant returns Option<NextState>; the
error variants preserve the caller's distinction between transient,
fatal-advance, and fatal-terminate.
The receiver side of v2 had a single blanket cancel implementation
that always terminated the session and handed the wallet an
Option<Transaction>. Fatal protocol errors emitted Closed(Failure)
directly. Both shapes lost the wallet's obligation to broadcast the
original transaction across a restart whenever a fallback existed.

Replace the blanket cancel with typestate-aware impls:
- impl<S: HasFallback> Receiver<S>::cancel advances to PendingFallback
- Receiver<Initialized>::cancel and Receiver<UncheckedOriginalPayload>
  ::cancel terminate with Closed(Cancel); neither holds a verified
  fallback
- Receiver<HasReplyableError>::cancel forks on the optional fallback:
  Some advances to PendingFallback, None terminates with Closed(Cancel)
spacebear21 and others added 2 commits June 3, 2026 11:56
This adds test vectors for
- SessionEvent::ProtocolFailed results in ReceiveSession::PendingFallback
- ReceiveSession::PendingFallback results in RecieveSession::Closed
@benalleng
benalleng force-pushed the fallback-typestate branch 3 times, most recently from de685c4 to fe054ce Compare June 3, 2026 16:19
@benalleng

benalleng commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

With 461b714 I followed the existing pattern as closely as I could but decided to leave NextState as the only generic instead of adding ErrorState like there is in MaybeFatalTransition.

@benalleng
benalleng force-pushed the fallback-typestate branch from fe054ce to 461b714 Compare June 3, 2026 18:05

@DanGould DanGould left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm inclined to approve the approach with 461b714 taken after mob programming. Without an implementation using it, it's hard to say for sure whether or not it's the right shape or not. I'd like @xstoicunicornx to weigh in here as well.

My thinking is that we can merge and make progress toward 1.0 in spacebear's stead, cut an rc on this change even if there's demand for it, have him review as a 1.0 milestone audit item and then we're not blocking ourselves from moving onto integrations. We can assume that we're doing it at this point.

no_broadcast: bool,
role: Option<CliRole>,
) -> Result<()> {
if let Some(role) = role {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@benalleng for a note, this is the thing we may be able to remove if we're using some sort of random id for sessions. If we're using the 64 bit ShortID, there is some nonzero probability of a collision but ergonomics improve a bit. I think you mentioned running into session_id AUTOINCREMENT vs random in the Liana integration.

@benalleng
benalleng requested a review from DanGould June 4, 2026 13:39
@benalleng

Copy link
Copy Markdown
Collaborator

Re-requesting review on 5d63cb1 as a quick implementation of this in payjoin-cli

@xstoicunicornx

Copy link
Copy Markdown
Collaborator

Will be looking at this today. I only went through the core implementation on my initial review so will take a bit longer than just reviewing latest couple commits.

Comment thread payjoin-cli/src/app/v2/mod.rs Outdated
Comment on lines +1043 to +1053
Some(pending) => {
// Fatal directory response, but the receiver still can
// broadcast the fallback typestate.
self.wallet().broadcast_tx(pending.fallback_tx())?;
println!(
"Broadcasted fallback transaction txid: {}",
pending.fallback_tx().compute_txid()
);
pending.close().save(persister)?;
}
None => return Err(anyhow!("Failed to process error response")),

@DanGould DanGould Jun 4, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that this does address my concern that it's possible to broadcast after failure, but I think we want to keep the ergonomics from before where if this state happens we just notify the user in UI and prompt them to fallback manually on their own if they so choose rather than automatically broadcast when presented with failure at this point.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tend to agree. What do you envision the closing of PendingFallback flow to look like?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are other places where we print that "hey this thing is ready for broadcast" like this https://github.com/payjoin/rust-payjoin/pull/1542/changes#diff-d04945444bc1d7bd2a2f15e0767850eb8ce326357cd0b72034e4e20b30eb6a01R264

That's what I'd expect

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

edit link: Like this

println!("Session {id} failed. Run `payjoin-cli cancel {id}` to cancel and broadcast the original transaction.");
. I meant to check the other receive sites too to make sure they're manual and not bundled. I think that was my review on the last iteration of this but not sure if it stands that way here too. Basically, sender and receiver are interacting, it's not a web shop, so it's ok if the receiver'd rather the sender try again than auto-broadcast their fallback.

@benalleng
benalleng force-pushed the fallback-typestate branch from 5d63cb1 to 2eedcd3 Compare June 4, 2026 18:10

@DanGould DanGould left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's go with ACK 2eedcd3, move on to attempt integrations with it, roll an rc asap, and have @spacebear21 confirm the design upon his return. I think this is solid forward movement in the right direction.

I thought about the cancel --no-broadcast semantics in review and confirm I think this is the correct semantics. cancel matches cancel() & SessionOutcome::Cancel and defaults to a broadcast. If you want the special case where you don't broadcast that's an option with --no-broadcast.

Perhaps though, we separate cancel (which can mean stop broadcasting a transaction that pays the counterparty in RBF cancel) into abort and abort --broadcast, flipping the semantics. What's weird here is after cancel payjoin-cli has no way to broadcast a transaction by replaying until the PendingFallback state. I think we want there to be a way to broadcast the fallback on a closed session, but that's a matter for payjoin-cli-1.0, not the payjoin-1.0 milestone issue this PR tries to address.

@xstoicunicornx xstoicunicornx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for posterity - utACK 2eedcd3

Only had one small comment that I will create follow up PR for.

let pending_fallback = err.error_state().expect("pending fallback should be carried");

assert_eq!(pending_fallback.fallback_tx(), &expected_tx);
// assert!(err.api_error_ref().is_some());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be uncommented?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants